fix(bin): prevent cross-home firstmate operations - #73
Merged
Merged
Conversation
FM_HOME selects which home's data/, state/, config/, and projects/ a command operates on, so a secondmate process whose FM_HOME named another home operated on that home with full authority. That happened twice: a secondmate spawned a worker into the primary home, and a secondmate's memory sweep read and rewrote the primary home's captain and learning records. fm-spawn's primary-only domain-mate check could not stop the first, because it inspects $FM_HOME - the value that was already wrong - rather than the running process. bin/fm-home-identity-lib.sh is the new owner of home identity and of the refusal. fm-spawn, fm-send, fm-startup-memory-budget, and fm-stow-cascade source it and exit 4 before any spawn, steer, or memory accounting when the selected home is not this process's own. It fails closed on either of two independent signals: the executing code root's own corroborated secondmate identity, which also covers a sibling mate; and the FM_PUBLIC_FOLLOWUP_PRIMARY_HOME session binding, which covers a mate that invokes the primary home's own bin/ by absolute path. An identity marker counts only when the home's durable parent binding and that parent's registry place the same id at the same path, so a marker left behind in a re-leased pool worktree establishes nothing. The refusal is one-way: a primary home keeps reaching the mates it owns, which the stow cascade, backlog handoff, and --secondmate spawns depend on, while the primary home's own data stays read-only from every mate and worker. Also fix a test-hermeticity bug found on the way: the remote-secondmate and cursor-primary fixtures built their fake code roots by tarring the runner's checkout without excluding the gitignored home identity files, so running the suite from any real secondmate home handed that home's identity to the fixture and tripped the pre-existing primary-only guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HRrBY9ykN3yGy1N2QbYzHh
…ve fixture in tests/fm-spawn-batch.test.sh to use the selected home’s canonical projects directory. Focused batch and home-identity tests pass, along with bash syntax and diff checks
bingb0t5
force-pushed
the
fm/fm-spawn-secondmate-fm-home-guard
branch
from
September 16, 2026 04:28
72da14d to
86de160
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
CEO overview
What changed technically
The environment variable FM_HOME selects which home's data, state, config, and projects directory a command operates on, and every fm-* entrypoint resolves it identically. A secondmate process whose FM_HOME named a different home therefore operated on that home with full authority. The existing primary-only domain-mate check in bin/fm-spawn.sh could not stop the spawn incident, because it inspects $FM_HOME, which is the value that was already wrong, rather than the running process.
New library bin/fm-home-identity-lib.sh owns the refusal and how a home's identity is read for it; bin/fm-home-seed.sh still owns writing the marker. Guarded entrypoints are bin/fm-spawn.sh for every spawn kind including --secondmate, bin/fm-startup-memory-budget.sh and bin/fm-stow-cascade.sh for stow memory operations, and bin/fm-send.sh for steering. Each refuses with exit status 4 after FM_HOME is resolved and before anything is read for a write, spawned into, or steered, including before any library that would create the target's state directory.
Accepted scope, settled during review: this is an accidental-misrouting guard, not process provenance or impersonation protection, and the library's LIMIT section states that plainly. A process can unset or alter its inherited environment, leaving only code-root protection. Remotely placed homes run from their host's separate tracked code root, which carries no identity marker, so this guard neither establishes a remote session's own-home provenance nor protects a same-host sibling from it. Use of the primary home's own scripts by absolute path gets primary-only containment rather than sibling protection. The identity marker is a routing convention and never authorization on its own.
Two signals refuse, either alone. The code-root signal is the identity of the home whose bin directory is executing, resolved from the library's own physical location so FM_ROOT_OVERRIDE cannot relabel it; it covers a mate reaching the primary home and is the only signal covering a mate reaching a sibling. It counts only when corroborated: the code root must carry the .fm-secondmate-parent binding whose local parent registers that id at that exact path in data/secondmates.md. That requirement exists because the marker is gitignored and a pooled task worktree can be re-leased from a retired home with its marker still present; a retired marker is registered nowhere and establishes nothing. The launch-binding signal uses FM_PUBLIC_FOLLOWUP_PRIMARY_HOME, already stamped into every secondmate session, to contain accidental selection of the primary home through the primary's own scripts.
Directory overrides are covered too, since FM_STATE_OVERRIDE and its siblings reach the same directories FM_HOME selects. An override is refused only when it resolves inside another registry-corroborated home; an unrelated explicit directory keeps working, which preserves the alternate-directory capability the test suite exercises. Identity validation accepts exactly the character and shape contract the shared registry parser accepts, with no stricter local policy, and rejects an unsafe marker - a symlink, a directory, an empty file, a second line, or NUL bytes - rather than silently treating it as the unmarked primary.
The refusal is one-way, which protects the primary home's records from ordinary mate and worker execution while keeping deliberate correct selections working. Neither signal fires when the executing home is the primary, so the memory cascade running each mate's own accounting, backlog handoff into a mate's queue, and standing a mate up all continue unchanged. A home operating on itself is always allowed. Refusal diagnostics name the resolved identities and canonical paths involved and carry no credential or message content.
Documentation is updated without duplicating the contract: the library header owns it, docs/architecture.md gains a cross-home boundary section beside the existing gate boundary, docs/configuration.md and the four entrypoint help surfaces carry pointers, and the stow and secondmate-provisioning skills carry their trigger lines. A pre-existing truncation in the startup-memory help output is fixed in the same pass.
Also fixed, found while validating: four test fixtures built their fake code roots by copying the runner's checkout without excluding the gitignored home identity files, so running the suite from a real secondmate home handed that home's identity to the fixture.
Validation
Module-boundary decision
Current module retained: the four entrypoints keep their existing boundaries and the refusal lives in one shared helper library beside the existing gate-refusal helper, following that established pattern, so the contract is stated once and each entrypoint carries only a call and a pointer.
Decision needed
No decision required.
What Changed
Risk Assessment
✅ Low: Captain, the change is a bounded, source-consistent accidental-misrouting guard with documented limitations and no substantiated remaining defect in the reviewed paths.
Testing
Focused end-to-end CLI validation passed: the new regression suite exercised all guarded interfaces and routing boundaries, while direct target and base-commit fixtures demonstrated the incident changing from a primary inbox write to an exit-4 refusal with no protected write. No UI applies to this CLI and filesystem-safety change.
Evidence: Target CLI refusal and override transcript
Source: Target CLI refusal and override transcript
Evidence: Baseline incident reproduction transcript
Source: Baseline incident reproduction transcript
Evidence: Focused end-to-end suite transcript
Source: Focused end-to-end suite transcript
Evidence: Focused test timing record
Source: Focused test timing record
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 1 issue found → auto-fixed (2) ✅
bin/fm-home-identity-lib.sh:315- Intent requires: “An override is refused only when it resolves inside another registry-corroborated home; an unrelated explicit directory keeps working.” Lines 315-318 instead turn any non-canonicalizable override into an exit-4 refusal. For example, a primary runningFM_STATE_OVERRIDE=/tmp/new-state bin/fm-spawn.sh ...now exits before the pre-existingmkdir -p "$STATE"path can create that explicit state directory. Preserve the documented alternate-directory behavior by allowing an unrelated missing directory while still detecting an existing/canonicalizable protected-home ancestor.🔧 Fix applied.
1 error still open:
bin/fm-home-identity-lib.sh:310- The required criterion says an override must be rejected when a registered protected home is an ancestor of its target. The new check only recognizes<protected-home>/<surface>paths. For example, a mate running its own bin withFM_HOME=<mate>andFM_STATE_OVERRIDE=<primary>(or<primary>/scratch) passes lines 310-320 because neither path matches<primary>/state;fm-spawnthen uses that path asSTATEand can create/write task records there. Reject any override whose canonical existing-prefix walk has a corroborated protected home ancestor, rather than only its canonical surface subdirectory.🔧 Fix applied.
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
bin/fm-session-start.shbin/fm-test-run.sh tests/fm-home-identity.test.sh --json ~/.no-mistakes/evidence/01M2M5WNS1YA07KND9XEC7VH61/fm-home-identity-results.jsonDirect isolated target CLI execution offm-send.shandfm-spawn.shDirect isolated base-commit reproduction usinggit archive fe6ccb727b5fcb2520362044182e908aff16d12c✅ **Document** - passed
✅ No issues found.
🔧 **Lint** - 1 issue found → auto-fixed ✅
🔧 Fix applied.
✅ Re-checked - no issues remain.
✅ **Push** - passed
✅ No issues found.